package mike.util.csv;

import java.io.*;
import java.util.*;

/**
 * This class represents a reader of a CSV document.
 * 
 * This documentation makes a distinction between "rows" and "lines."  In the context of
 * this CSV reader.  A "row" is one row of data fields, while a "line" is one line in the file.
 * A row can be made up of multiple lines, and reading a "row" from the file may result
 * in reading multiple lines.
 * 
 * @author murbanski004
 *
 */
public class CSVReader implements Closeable
{
	/**
	 * The default field separator character
	 */
	public static final char DEFAULT_SEPARATOR = ',';
	/**
	 * The quotation character.  Currently not configurable, but may be in the future.
	 */
	private static final char QUOTE_CHARACTER = '"';

	/**
	 * The underlying file reader.
	 */
	private Scanner reader;
	/**
	 * The separator character used by this instance.
	 */
	private char separator;
	/**
	 * Whether or not the first row of the file is a header row.
	 */
	private boolean header;
	/**
	 * If true, then the reader will ensure that each row returned has the exact same
	 * number of fields as the table does columns (as indicated by the number of headers).  
	 * If the number of fields is less than the number of columns, then empty strings
	 * are added to the row.  If the number of fields is greater than the number of columns,
	 * then the extra fields are truncated.  For example, the document
	 * 
	 * id,location
	 * 1
	 * 2,New York,New York
	 * 
	 * would yield the rows
	 * 
	 * {"1", ""}
	 * {"2", "New York"}
	 * 
	 * with this option turned on.
	 * 
	 */
	private boolean keepNumberOfColumnsConsistent; 
	/**
	 * True if and only if there is another row to be read
	 */
	private boolean hasNext = true;
	/**
	 * Indicates whether a line in the file ended with an escaped newline; if so,
	 * the next line is considered a part of the same row.
	 */
	private boolean pending = false;
	/**
	 * The number of columns to which to trim or pad each row if keepNumberOfColumnsConsistent
	 * is true.
	 */
	private int numCols;
	/**
	 * Maps column names to indices in the lastLine array
	 */
	private Map<String, Integer> headerMap = null;
	/**
	 * Holds the last-returned line for use with the getValue method.
	 */
	private String[] lastLine;
	/**
	 * Whether or not a row has been read by this reader.
	 */
	private boolean rowRead = false;
	/**
	 * The row number that was last returned by a call to getNextRow, or 0 if no rows have been
	 * returned.
	 */
	private int lastRowNumber;
	
	/**
	 * Construct a new CSVReader of the specified filename, default separator (comma),
	 * and no header row
	 * @param filename The filename of the CSV file to read
	 * @throws FileNotFoundException If the file cannot be found
	 * @throws IOException If there is an error opening the file
	 * @throws NullPointerException if filename is null
	 */
	public CSVReader(String filename) throws FileNotFoundException, IOException
	{
		this(filename, DEFAULT_SEPARATOR, false, false, 1);
	}
	/**
	 * Construct a new CSVReader of the specified filename with the specified separator
	 * and no header row
	 * @param filename The filename of the CSV file to read
	 * @param separator The separator character to be used when parsing the file
	 * @throws FileNotFoundException If the file cannot be found
	 * @throws IOException If there is an error opening the file
	 * @throws IllegalArgumentException If separator is a '"', '\n', or '\r'
	 * @throws NullPointerException if filename is null
	 */
	public CSVReader(String filename, char separator) throws FileNotFoundException, IOException
	{
		this(filename, separator, false, false, 1);
	}
	/**
	 * Construct a new CSVReader of the specified filename, default separator (comma),
	 * and a header row
	 * @param filename The filename of the CSV file to read
	 * @param header
	 * @throws FileNotFoundException If the file cannot be found
	 * @throws IOException If there is an error opening the file
	 * @throws NullPointerException if filename is null
	 */
	public CSVReader(String filename, boolean header) throws FileNotFoundException, IOException
	{
		this(filename, DEFAULT_SEPARATOR, header, false, 1);
	}
	/**
	 * Construct a new CSVReader of the specified filename, default separator (comma),
	 * a header row, and keep the number of columns consistent
	 * @param filename The filename of the CSV file to read
	 * @param header Whether the first row of the file is a header row
	 * @param keepNumberOfColumnsConsistent If true, then the reader will ensure that each row returned has the exact same
	 * number of fields as the table does columns (as indicated by the number of headers).  
	 * If the number of fields is less than the number of columns, then empty strings
	 * are added to the row.  If the number of fields is greater than the number of columns,
	 * then the extra fields are truncated.  For example, the document
	 * 
	 * id,location
	 * 1
	 * 2,New York,New York
	 * 
	 * would yield the rows
	 * 
	 * {"1", ""}
	 * {"2", "New York"}
	 * 
	 * with this option turned on. 
	 * @throws FileNotFoundException If the file cannot be found
	 * @throws IOException If there is an error opening the file
	 * @throws IllegalArgumentException If header is false but keepNumberOfColumnsConsistent is true
	 * @throws NullPointerException if filename is null
	 */
	public CSVReader(String filename, boolean header, boolean keepNumberOfColumnsConsistent) throws FileNotFoundException, IOException
	{
		this(filename, DEFAULT_SEPARATOR, header, keepNumberOfColumnsConsistent, 1);
	}
	/**
	 * Construct a new CSVReader of the specified filename with the specified separator
	 * and a header row
	 * @param filename The filename of the CSV file to read
	 * @param separator The separator character to be used when parsing the file
	 * @param header Whether the first row of the file is a header row
	 * @throws FileNotFoundException If the file cannot be found
	 * @throws IOException If there is an error opening the file
	 * @throws IllegalArgumentException If separator is a '"', '\n', or '\r'
	 * @throws NullPointerException if filename is null
	 */
	public CSVReader(String filename, char separator, boolean header) throws FileNotFoundException, IOException
	{
		this(filename, separator, header, false, 1);
	}
	/**
	 * Construct a new CSVReader of the specified filename with the specified separator,
	 * a header row, and keep the number of columns consistent
	 * @param filename The filename of the CSV file to read
	 * @param separator The separator character to be used when parsing the file
	 * @param header Whether the first row of the file is a header row
	 * @param keepNumberOfColumnsConsistent
	 * @throws FileNotFoundException If the file cannot be found
	 * @throws IOException If there is an error opening the file
	 * @throws IllegalArgumentException If header is false but keepNumberOfColumnsConsistent is true
	 * @throws IllegalArgumentException If separator is a '"', '\n', or '\r'
	 * @throws NullPointerException if filename is null
	 */
	public CSVReader(String filename, char separator, boolean header, boolean keepNumberOfColumnsConsistent) throws FileNotFoundException, IOException
	{
		this(filename, separator, header, keepNumberOfColumnsConsistent, 1);
	}
	
	/**
	 * Construct a new CSVReader of the specified filename, default separator (comma),
	 * and no header row
	 * @param filename The filename of the CSV file to read
	 * @param startRow The row at which to start reading, starting at 1, not counting the header
	 * @throws FileNotFoundException If the file cannot be found
	 * @throws IOException If there is an error opening the file or if startRow is greater than
	 * the number of rows in the file.
	 * @throws IllegalArgumentException If startRow is not positive
	 * @throws NullPointerException if filename is null
	 */
	public CSVReader(String filename, int startRow) throws FileNotFoundException, IOException
	{
		this(filename, DEFAULT_SEPARATOR, false, false, startRow);
	}
	/**
	 * Construct a new CSVReader of the specified filename with the specified separator
	 * and no header row
	 * @param filename The filename of the CSV file to read
	 * @param separator The separator character to be used when parsing the file
	 * @param startRow The row at which to start reading, starting at 1, not counting the header
	 * @throws FileNotFoundException If the file cannot be found
	 * @throws IOException If there is an error opening the file or if startRow is greater than
	 * the number of rows in the file.
	 * @throws IllegalArgumentException If separator is a '"', '\n', or '\r'
	 * @throws IllegalArgumentException If startRow is not positive
	 * @throws NullPointerException if filename is null
	 */
	public CSVReader(String filename, char separator, int startRow) throws FileNotFoundException, IOException
	{
		this(filename, separator, false, false, startRow);
	}
	/**
	 * Construct a new CSVReader of the specified filename, default separator (comma),
	 * and a header row
	 * @param filename The filename of the CSV file to read
	 * @param header Whether the first row of the file is a header row
	 * @param startRow The row at which to start reading, starting at 1, not counting the header
	 * @throws FileNotFoundException If the file cannot be found
	 * @throws IOException If there is an error opening the file or if startRow is greater than
	 * the number of rows in the file.
	 * @throws IllegalArgumentException If startRow is not positive
	 * @throws NullPointerException if filename is null
	 */
	public CSVReader(String filename, boolean header, int startRow) throws FileNotFoundException, IOException
	{
		this(filename, DEFAULT_SEPARATOR, header, false, startRow);
	}
	/**
	 * Construct a new CSVReader of the specified filename, default separator (comma),
	 * a header row, and keep the number of columns consistent
	 * @param filename The filename of the CSV file to read
	 * @param header Whether the first row of the file is a header row
	 * @param keepNumberOfColumnsConsistent If true, then the reader will ensure that each row returned has the exact same
	 * number of fields as the table does columns (as indicated by the number of headers).  
	 * If the number of fields is less than the number of columns, then empty strings
	 * are added to the row.  If the number of fields is greater than the number of columns,
	 * then the extra fields are truncated.  For example, the document
	 * 
	 * id,location
	 * 1
	 * 2,New York,New York
	 * 
	 * would yield the rows
	 * 
	 * {"1", ""}
	 * {"2", "New York"}
	 * 
	 * with this option turned on. 
	 * @param startRow The row at which to start reading, starting at 1, not counting the header
	 * @throws FileNotFoundException If the file cannot be found
	 * @throws IOException If there is an error opening the file or if startRow is greater than
	 * the number of rows in the file.
	 * @throws IllegalArgumentException If header is false but keepNumberOfColumnsConsistent is true
	 * @throws IllegalArgumentException If startRow is not positive
	 * @throws NullPointerException if filename is null
	 */
	public CSVReader(String filename, boolean header, boolean keepNumberOfColumnsConsistent, int startRow) throws FileNotFoundException, IOException
	{
		this(filename, DEFAULT_SEPARATOR, header, keepNumberOfColumnsConsistent, startRow);
	}
	/**
	 * Construct a new CSVReader of the specified filename with the specified separator
	 * and a header row
	 * @param filename The filename of the CSV file to read
	 * @param separator The separator character to be used when parsing the file
	 * @param header Whether the first row of the file is a header row
	 * @param startRow The row at which to start reading, starting at 1, not counting the header
	 * @throws FileNotFoundException If the file cannot be found
	 * @throws IOException If there is an error opening the file or if startRow is greater than
	 * the number of rows in the file.
	 * @throws IllegalArgumentException If separator is a '"', '\n', or '\r'
	 * @throws IllegalArgumentException If startRow is not positive
	 * @throws NullPointerException if filename is null
	 */
	public CSVReader(String filename, char separator, boolean header, int startRow) throws FileNotFoundException, IOException
	{
		this(filename, separator, header, false, startRow);
	}
	/**
	 * Construct a new CSVReader of the specified filename with the specified separator,
	 * a header row, and keep the number of columns consistent
	 * @param filename The filename of the CSV file to read
	 * @param separator The separator character to be used when parsing the file
	 * @param header Whether the first row of the file is a header row
	 * @param keepNumberOfColumnsConsistent If true, then the reader will ensure that each row returned has the exact same
	 * number of fields as the table does columns (as indicated by the number of headers).  
	 * If the number of fields is less than the number of columns, then empty strings
	 * are added to the row.  If the number of fields is greater than the number of columns,
	 * then the extra fields are truncated.  For example, the document
	 * 
	 * id,location
	 * 1
	 * 2,New York,New York
	 * 
	 * would yield the rows
	 * 
	 * {"1", ""}
	 * {"2", "New York"}
	 * 
	 * with this option turned on. 
	 * @param startRow The row at which to start reading, starting at 1, not counting the header
	 * @throws FileNotFoundException If the file cannot be found
	 * @throws IOException If there is an error opening the file or if startRow is greater than
	 * the number of rows in the file.
	 * @throws IllegalArgumentException If header is false but keepNumberOfColumnsConsistent is true
	 * @throws IllegalArgumentException If separator is a '"', '\n', or '\r'
	 * @throws IllegalArgumentException If startRow is not positive
	 * @throws NullPointerException if filename is null
	 */
	public CSVReader(String filename, char separator, boolean header, boolean keepNumberOfColumnsConsistent, int startRow) throws FileNotFoundException, IOException
	{
		if (!header && keepNumberOfColumnsConsistent)
			throw new IllegalArgumentException("There must be a header row to keep the number of columns consistent.");
		if (separator == '"' || separator == '\n' || separator == '\r')
			throw new IllegalArgumentException("Separator character must not be \", \n, or \r");
		if (startRow < 1)
			throw new IllegalArgumentException("The starting row must be positive");
		
		reader = new Scanner(new FileReader(new File(filename)));
		this.separator = separator;
		this.header = header;
		this.keepNumberOfColumnsConsistent = keepNumberOfColumnsConsistent;
		
		if (header) 
		{
			parseHeader();
			
			if (keepNumberOfColumnsConsistent)
			{
				numCols = headerMap.size();
				//create this array now because its size will never change
				lastLine = new String[numCols];
			}
		}
		lastRowNumber = 0;
		if (!skip(startRow - 1))
			throw new IOException("End of file reached before the specified row number.");
		else
			lastRowNumber = startRow - 1;
	}
	
	/**
	 * Get the next row from the file and return it as a list
	 * @return A list of fields in the next row of the file
	 * @throws IOException If there is an error reading the file
	 */
	private List<String> getNextRowAsList() throws IOException
	{
		//Get the (possibly incomplete) first line of the row
		List<String> fields = parseNextLine(); 
		
		//Until the row is complete (all lines read), continue to read
		//additional lines.  The first element of each returned list is a continuation
		//of the last element of "fields."
		while (pending)
		{
			List<String> nextRow = parseNextLine();
			//Remove the first element and concatenate it to the end of the last
			String first = nextRow.remove(0); 
			fields.set(fields.size() - 1, fields.get(fields.size() - 1) + first);
			fields.addAll(nextRow);
		}
		
		return fields;
	}
	
	/**
	 * Parses the first row of the file and sets up the column-to-index map
	 * @throws IOException If there is an error reading the file
	 */
	private void parseHeader() throws IOException
	{
		List<String> fields = getNextRowAsList();
		
		headerMap = new HashMap<String, Integer>();
		int i = 0;
		for (String s : fields)
			headerMap.put(s, i++);
	}
	/**
	 * Get the field in the last returned row in the specified column
	 * @param columnName The name of the column (specified in the file's header row)
	 * for which to get the field
	 * @return The field in the last returned row in the specified column, or null if
	 * the index of this column is greater than the last field in this row, which can
	 * occur if keepNumberOfColumnsConsistent is false and the last returned row
	 * has fewer fields than columns
	 * @throws IllegalStateException If headers is false or if a row has not yet been read
	 * @throws IllegalArgumentException If the column name does not exist
	 */
	public String getValue(String columnName)
	{
		if (!header)
			throw new IllegalStateException("Headers must be enabled to retrieve values by column name.");
		if (!rowRead)
			throw new IllegalStateException("A row has not yet been read.");
		
		Integer c = headerMap.get(columnName);
		
		if (c == null)
			throw new IllegalArgumentException("Column doesn't exist");
		else if (c >= lastLine.length)
			return null;
		else		
			return lastLine[headerMap.get(columnName)];
	}
	/**
	 * Get an array containing the fields in the next row of data in the file
	 * @return An array containing the fields in the next row of data in the file
	 * @throws IOException If there is an error reading the file
	 */
	public String[] getNextRow() throws IOException
	{
		rowRead = true;
		
		List<String> fields = getNextRowAsList();
		
		lastRowNumber++;
		
		//Pad the row with empty strings if necessary
		if (keepNumberOfColumnsConsistent && fields.size() < numCols)
		{
			int n = numCols - fields.size();
			while (n-- > 0) fields.add("");
		}
		//Trim extra fields if necessary
		else if (keepNumberOfColumnsConsistent && fields.size() > numCols)
		{
			int n = fields.size();
			while (n-- > numCols) fields.remove(n);
		}
		
		String[] ret = fields.toArray(new String[0]);
		
		//If header is true, then set up the lastLine array
		//for use with "getValue."  If header is false,
		//then lastLine has no use so we can skip this.
		if (header)
		{
			if (!keepNumberOfColumnsConsistent)
				lastLine = new String[ret.length];
			
			System.arraycopy(ret, 0, lastLine, 0, fields.size());
		}
		
		//Because the entire row has been read, the file has another role
		//if and only if it has another line
		hasNext = reader.hasNextLine();
		
		return ret;
	}
	/**
	 * Parse the next line of the file
	 * @return A list of fields in the next line of the file.  The last
	 * element of this list may or may not be complete depending on whether
	 * any fields contain a new line.
	 * @throws IOException If there is an error reading the file
	 */
	private List<String> parseNextLine() throws IOException
	{
		String nextLine = getNextLineRaw();
		
		//Whether we are currently reading an escaped field.
		//In the case that we are continuing from a previous line,
		//this value will start as true. Otherwise it will start as false
		boolean inQuotes = pending;
		
		//Whether or not the current character is the first of a new field
//		boolean startOfEntry = !pending;
		
		StringBuilder sb = new StringBuilder();
		List<String> fields = new LinkedList<String>();
		
		for (int i = 0; i < nextLine.length(); i++)
		{
			char c = nextLine.charAt(i);
			if (c == separator)
			{
				if (inQuotes) //if this field is escaped, then the separator is part of the field
				{
					sb.append(c);
//					startOfEntry = false;
				}
				else //otherwise, the field is terminated.  Add it and start over
				{
					fields.add(sb.toString());
					sb.setLength(0);
//					startOfEntry = true;
				}
			}
			else if (c == QUOTE_CHARACTER)
			{
//				if (startOfEntry)
//				{
					if (!inQuotes) //Begin escape sequence
						inQuotes = true;
					else //This quotation either ends an escape sequence or escapes a literal quotation
					{
						//If the next character is also a quotation, then add a literal quote
						//to the string and skip the next character
						if (i < nextLine.length() - 1 && nextLine.charAt(i + 1) == QUOTE_CHARACTER)
						{
							sb.append(c);
//							startOfEntry = false;
							i++;
						}
						else
							inQuotes = false;
					}
//				}
//				else
//				{
//					sb.append(c);
//					startOfEntry = false;
//				}
			}
			else
			{
				sb.append(c);
//				startOfEntry = false;
			}
		}
		
		//If we are still in an escape sequence, then the field contains 
		//a literal newline character.  In that case, append this literal newline.
		if (inQuotes)
			sb.append('\n');
		
		//Add the final (possibly unfinished) field to the list
		fields.add(sb.toString());
		pending = inQuotes;
		
		return fields;
	}
	
	/**
	 * Read and return the specified number of rows
	 * @param number The number of rows to read
	 * @return The next "number" rows in the file, or all remaining rows if number is greater
	 * than the number remaining
	 * @throws IOException If there is an error reading the file
	 * @throws IllegalArgumentException If number is not positive
	 * @throws IllegalStateException If the end of file has already been reached
	 */
	public List<String[]> getMultipleRows(int number) throws IOException
	{
		if (!hasNext)
			throw new IllegalStateException("End of file has already been reached.");
		if (number < 1) 
			throw new IllegalArgumentException("The number of rows to read must be positive.");
		
		List<String[]> rows = new ArrayList<String[]>(number);
		
		while (hasNext && number-- > 0)
			rows.add(getNextRow());
		
		return rows;
	}
	
	/**
	 * Read and return all remaining rows in the file
	 * @return All remaining rows in the file
	 * @throws IOException If there is an error reading the file
	 * @throws IllegalStateException If the end of file has already been reached
	 */
	public List<String[]> getAllRemainingRows() throws IOException
	{
		if (!hasNext)
			throw new IllegalStateException("End of file has already been reached.");
		
		List<String[]> rows = new ArrayList<String[]>();
		
		while (hasNext)
			rows.add(getNextRow());
		
		return rows;
	}
	
	/**
	 * Return the next line in the file as a String
	 * @return the next line in the file as a String
	 * @throws IOException If there is an error reading the file
	 */
	private String getNextLineRaw() throws IOException
	{
		return reader.nextLine();
	}
	
	/**
	 * Skip the next row in the file.
	 * @throws IOException If there is an error reading the file
	 * @throws IllegalStateException If the end of the file has already been reached
	 */
	public void skip() throws IOException
	{
		skip(1);
	}
	
	/**
	 * Skip the specified number of rows in the file.  If end of file is reached before
	 * the specified number of rows have been skipped, no error is thrown and this
	 * method returns false. 
	 * @param number The number of rows to skip
	 * @throws IOException If there is an error reading the file
	 * @throws IllegalStateException If the end of the file has already been reached before
	 * this method call
	 * @throws IllegalArgumentException If number is negative
	 * @return true if number rows were skipped, false if end of file was reached before
	 * the specified number of rows were skipped
	 */
	public boolean skip(int number) throws IOException
	{
		if (!hasNext)
			throw new IllegalStateException("End of file reached.");
		if (number < 0)
			throw new IllegalArgumentException("Number to skip must be nonnegative.");
		
		try 
		{
			while (number-- > 0)
				getNextRowAsList();
			return true;
		}
		catch (NoSuchElementException e) { return false; }
	}
	
	@Override
	public void close() throws IOException
	{
		reader.close();
	}
	/**
	 * Check whether this reader has additional rows to read
	 * @return True if and only if there are additional rows to read
	 */
	public boolean hasNext()
	{
		return hasNext;
	}
	/**
	 * Get the last row that was read (or skipped) by this reader, not counting the header.
	 * @return The number of the last row that was read or skipped, or 0 if this read as at
	 * the beginning of the file.
	 */
	public int getLastRowNumber()
	{
		return lastRowNumber;
	}
}
